Skip to main content

futu_opend/
config.rs

1//! v1.4.110 P1-2: XmlConfig + RuntimeConfig + load / merge 抽自 main.rs lines 498-842.
2
3use anyhow::Result;
4
5use crate::cli::{Args, LogLevel, LoginRegion, Platform};
6
7// ===== XML 配置文件解析 (兼容 C++ FutuOpenD.xml) =====
8//
9// v1.4.102 BUG-006 fix (P1, leaf v1.4.100 报告): `#[serde(deny_unknown_fields)]`
10// 让 TOML/XML 里未知字段 / 未知 section ([daemon]) 报 fatal error 而非 silent
11// drop. 历史: 用户写 `[daemon]\nport = 12482` (合理 namespace, 但 XmlConfig
12// 是 flat 结构), serde 把整个 `[daemon]` 作为未知字段 silent drop, daemon
13// log 显示 "loaded TOML config" 但端口仍是默认值 11111. P1 配置 silent 失效.
14//
15// **修法**: deny_unknown_fields → `[daemon]` section / typo 立即 fatal +
16// 启动 abort. 用户改成 flat keys (`port = 12482` 顶层) 才合法.
17//
18// **不接受 [daemon] section 的设计理由**: XmlConfig 是 quick_xml 生成的
19// flat schema, 加 nested DaemonSection 会破坏 XML 兼容路径 (XML 不分 section).
20// 一致性: TOML / XML 都用 flat keys, 文档明确说.
21#[derive(Default, serde::Deserialize)]
22#[serde(deny_unknown_fields)]
23pub struct XmlConfig {
24    // 登录参数
25    pub login_account: Option<String>,
26    pub login_pwd: Option<String>,
27    pub login_pwd_md5: Option<String>,
28    pub login_pwd_file: Option<String>,
29    // v1.4.40 #12 fix: XML/TOML 里 login_region 也封闭为 enum。serde 会拒非法值。
30    pub login_region: Option<LoginRegion>,
31    /// 账号平台(v1.4.14+)—— "futunn" 或 "moomoo"
32    pub platform: Option<Platform>,
33    // 服务监听
34    pub ip: Option<String>,
35    #[serde(alias = "api_port")]
36    pub port: Option<u16>,
37    // WebSocket
38    pub websocket_port: Option<u16>,
39    // Telnet
40    pub telnet_port: Option<u16>,
41    pub telnet_ip: Option<String>,
42    // REST API
43    pub rest_port: Option<u16>,
44    // gRPC
45    pub grpc_port: Option<u16>,
46    // RSA
47    pub rsa_private_key: Option<String>,
48    // 系统
49    pub lang: Option<String>,
50    // codex 0547 F3 (P2) fix: 改 Option<LogLevel> enum (与 clap ValueEnum
51    // 同源校验). 之前是 Option<String> + 运行时手动 LogLevel::from_str_opt,
52    // 非法值 (e.g. `log_level = "wtf"`) 走 eprintln + fallback to "info" —
53    // BUG-006 fatal-on-typo 语义不继承到此字段. 修后 serde 在 TOML/XML
54    // parse 阶段直接拒非法值 → daemon abort + 清晰错误 (包含合法 enum list).
55    //
56    // serde rename: LogLevel 已 `#[serde(rename_all = "lowercase")]`, 所以
57    // TOML `log_level = "info"` / `"debug"` / "off" 全合法, `"wtf"` /
58    // `"warning"` (老 alias) 此 path 直接 reject. 旧 lenient alias 只保留在
59    // `LogLevel::from_str_opt` 测试 helper 中,防止重新接回 production。
60    pub log_level: Option<LogLevel>,
61    // codex 0547 F6 (P3) fix: 扩展 schema 让 systemd / Docker 部署能把完整
62    // 配置 (含安全相关字段) 收敛进 TOML, 与 `--config` help 文 "字段与 CLI
63    // 一致" 契约对齐. 之前 schema 只覆盖登录/监听/RSA/lang/log_level 5 类;
64    // 用户在 TOML 写 `rest_keys_file = "..."` 触发 BUG-006 unknown field
65    // fatal. 现 schema 含安全字段, 文档同步白名单.
66    //
67    // CLI-only 字段 (故意不进 TOML, 见下方 `--config` help 段白名单):
68    // `device_id` / `reset_device` / `setup_only` / `verify_code` /
69    // `json_log` / `inject_auth_failure_every` — 运维 / 调试 / 一次性流程
70    // flag, 不适合写持久 config 文件.
71    pub rest_keys_file: Option<std::path::PathBuf>,
72    pub grpc_keys_file: Option<std::path::PathBuf>,
73    pub ws_keys_file: Option<std::path::PathBuf>,
74    pub audit_log: Option<std::path::PathBuf>,
75    pub allow_tcp_unauthenticated: Option<bool>,
76    /// IANA timezone name (e.g. "Asia/Hong_Kong"). 与 --tz CLI flag 同源.
77    pub tz: Option<String>,
78    /// Language-pack background update. Defaults true in RuntimeConfig.
79    pub language_pack_auto_update: Option<bool>,
80    /// Language-pack HTTPS endpoint or manifest URL. Empty means no network.
81    pub language_pack_endpoint: Option<String>,
82    /// Language-pack cache root.
83    pub language_pack_cache_dir: Option<std::path::PathBuf>,
84    /// Language-pack update HTTP timeout in milliseconds.
85    pub language_pack_update_timeout_ms: Option<u64>,
86    /// Long-runner opt-in: proactive client_sig refresh 1h before expiry.
87    pub client_sig_proactive_refresh: Option<bool>,
88    /// Long-runner opt-in: reactive client_sig refresh after repeated tcp_login failures.
89    pub client_sig_reactive_refresh: Option<bool>,
90}
91
92impl std::fmt::Debug for XmlConfig {
93    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94        let login_account_fp = self
95            .login_account
96            .as_deref()
97            .map(futu_backend::auth::redact::account_log_fingerprint);
98        let login_pwd = redact_debug_option(&self.login_pwd);
99        let login_pwd_md5 = redact_debug_option(&self.login_pwd_md5);
100
101        f.debug_struct("XmlConfig")
102            .field("login_account_fp", &login_account_fp)
103            .field("login_pwd", &login_pwd)
104            .field("login_pwd_md5", &login_pwd_md5)
105            .field("login_pwd_file", &self.login_pwd_file)
106            .field("login_region", &self.login_region)
107            .field("platform", &self.platform)
108            .field("ip", &self.ip)
109            .field("port", &self.port)
110            .field("websocket_port", &self.websocket_port)
111            .field("telnet_port", &self.telnet_port)
112            .field("telnet_ip", &self.telnet_ip)
113            .field("rest_port", &self.rest_port)
114            .field("grpc_port", &self.grpc_port)
115            .field("rsa_private_key", &self.rsa_private_key)
116            .field("lang", &self.lang)
117            .field("log_level", &self.log_level)
118            .field("rest_keys_file", &self.rest_keys_file)
119            .field("grpc_keys_file", &self.grpc_keys_file)
120            .field("ws_keys_file", &self.ws_keys_file)
121            .field("audit_log", &self.audit_log)
122            .field("allow_tcp_unauthenticated", &self.allow_tcp_unauthenticated)
123            .field("tz", &self.tz)
124            .field("language_pack_auto_update", &self.language_pack_auto_update)
125            .field("language_pack_endpoint", &self.language_pack_endpoint)
126            .field("language_pack_cache_dir", &self.language_pack_cache_dir)
127            .field(
128                "language_pack_update_timeout_ms",
129                &self.language_pack_update_timeout_ms,
130            )
131            .field(
132                "client_sig_proactive_refresh",
133                &self.client_sig_proactive_refresh,
134            )
135            .field(
136                "client_sig_reactive_refresh",
137                &self.client_sig_reactive_refresh,
138            )
139            .finish()
140    }
141}
142
143fn redact_debug_option(value: &Option<String>) -> String {
144    match value {
145        Some(value) => format!("<REDACTED len={}>", value.len()),
146        None => "<NONE>".to_string(),
147    }
148}
149
150/// 从 XML 配置文件读取配置 (兼容 C++ <futu_opend> 格式)
151pub fn load_xml_config(path: &str) -> Result<XmlConfig> {
152    let content = std::fs::read_to_string(path)?;
153    let config: XmlConfig = quick_xml::de::from_str(&content)?;
154    tracing::info!(path, "loaded XML config");
155    Ok(config)
156}
157
158/// 从 TOML 配置文件读取配置 (v1.4.2+;字段与 XmlConfig 一致)
159///
160/// v1.4.102 BUG-006: XmlConfig 加了 `#[serde(deny_unknown_fields)]`, 任何
161/// section ([daemon] 等) / typo / unknown key 直接报 fatal error 而非
162/// silent drop (历史 P1 bug: `[daemon]\nport=12482` daemon log 说 loaded
163/// 但端口实际是 default 11111).
164pub fn load_toml_config(path: &str) -> Result<XmlConfig> {
165    let content = std::fs::read_to_string(path)?;
166    let config: XmlConfig = toml::from_str(&content).map_err(|e| {
167        // v1.4.102 BUG-006: 给 deny_unknown_fields 触发的错加 hint
168        let msg = e.to_string();
169        if msg.contains("unknown field") {
170            anyhow::anyhow!(
171                "TOML config parse error: {msg}\n\
172                 Hint (v1.4.102 BUG-006): TOML 配置必须用 **flat keys**, 不支持 \
173                 [section] 嵌套 (如 `[daemon]\\nport = X` 会触发本 error). \
174                 正确写法: `port = 12482` 直接放在文件顶层 (no [section] header). \
175                 详见 README.md §TOML 配置 / 项目根 deploy/examples/futu-opend.toml 示例."
176            )
177        } else {
178            anyhow::Error::from(e)
179        }
180    })?;
181    // tracing 初始化在 main 里,此时还没 subscriber,用 eprintln
182    eprintln!("[config] loaded TOML config from {path}");
183    Ok(config)
184}
185
186/// 合并后的运行时配置
187pub struct RuntimeConfig {
188    pub ip: String,
189    pub port: u16,
190    pub login_account: Option<String>,
191    pub login_pwd: Option<String>,
192    pub login_pwd_md5: Option<String>,
193    pub login_pwd_file: Option<String>,
194    pub login_region: String,
195    /// v1.4.42 (external reviewer v1.4.40 报告 P3.5 澄清): 标记用户是否显式传了 `--login-region`
196    /// (或在 config 文件里写了)。平台是 moomoo + 用户显式传 region → main() 入口 WARN
197    /// 明示此 flag 对 moomoo 是 noop。避免用户误以为 "layer 2/3 platform IP 按 region 切"。
198    pub login_region_explicit: bool,
199    pub platform: Platform,
200    pub auth_server: String,
201    pub device_id: Option<String>,
202    pub reset_device: bool,
203    pub setup_only: bool,
204    /// v1.4.57 UX-04: 直接传入 SMS 验证码跳过 stdin 交互(Telegram 中继等场景)
205    pub verify_code: Option<String>,
206    pub log_level: String,
207    pub websocket_port: Option<u16>,
208    pub telnet_port: Option<u16>,
209    pub telnet_ip: String,
210    pub rest_port: Option<u16>,
211    pub grpc_port: Option<u16>,
212    pub rsa_private_key: Option<String>,
213    pub json_log: bool,
214    pub lang: String,
215    // codex 0547 F6 (P3): TOML 安全字段 schema 扩展. main() 之前各自从
216    // `args.*` 拷, 现统一从 RuntimeConfig 读, TOML 配置生效路径打通.
217    pub rest_keys_file: Option<std::path::PathBuf>,
218    pub grpc_keys_file: Option<std::path::PathBuf>,
219    pub ws_keys_file: Option<std::path::PathBuf>,
220    pub audit_log: Option<std::path::PathBuf>,
221    pub allow_tcp_unauthenticated: bool,
222    pub tz: Option<String>,
223    pub language_pack_auto_update: bool,
224    pub language_pack_endpoint: Option<String>,
225    pub language_pack_cache_dir: Option<std::path::PathBuf>,
226    pub language_pack_update_timeout_ms: u64,
227    pub client_sig_proactive_refresh: bool,
228    pub client_sig_reactive_refresh: bool,
229}
230
231pub(crate) fn normalize_runtime_lang(raw: &str) -> &'static str {
232    let lang = raw.trim().to_ascii_lowercase().replace('-', "_");
233    match lang.as_str() {
234        "chs" | "zh" | "zh_cn" | "zh_sg" | "zh_hans" | "zh_hans_cn" | "zh_hans_sg" => "chs",
235        "cht" | "zh_tw" | "zh_hk" | "zh_mo" | "zh_hant" | "zh_hant_tw" | "zh_hant_hk"
236        | "zh_hant_mo" => "cht",
237        "en" | "en_us" | "en_gb" | "en_au" | "en_ca" => "en",
238        _ => "en",
239    }
240}
241
242pub(crate) fn runtime_lang_from_locale(locale: Option<&str>) -> &'static str {
243    let Some(locale) = locale.map(str::trim).filter(|s| !s.is_empty()) else {
244        return "en";
245    };
246    let primary = locale
247        .split([':', '.', '@'])
248        .next()
249        .unwrap_or(locale)
250        .replace('-', "_")
251        .to_ascii_lowercase();
252    normalize_runtime_lang(&primary)
253}
254
255pub(crate) fn app_lang_from_runtime_lang(raw: &str) -> i32 {
256    match normalize_runtime_lang(raw) {
257        "chs" => 0,
258        "cht" => 1,
259        "en" => 2,
260        _ => 2,
261    }
262}
263
264fn system_default_lang() -> String {
265    for key in ["LC_ALL", "LC_MESSAGES", "LANGUAGE", "LANG"] {
266        if let Some(lang) = std::env::var_os(key)
267            .and_then(|value| value.into_string().ok())
268            .map(|value| runtime_lang_from_locale(Some(&value)))
269            .filter(|lang| !lang.is_empty())
270        {
271            return lang.to_string();
272        }
273    }
274    "en".to_string()
275}
276
277fn merge_runtime_lang(cli_lang: Option<String>, config_lang: Option<String>) -> String {
278    cli_lang
279        .or(config_lang)
280        .map(|lang| normalize_runtime_lang(&lang).to_string())
281        .unwrap_or_else(system_default_lang)
282}
283
284fn clean_optional_string(value: Option<String>) -> Option<String> {
285    value
286        .map(|value| value.trim().to_string())
287        .filter(|value| !value.is_empty())
288}
289
290fn env_optional_string(key: &str) -> Option<String> {
291    std::env::var(key)
292        .ok()
293        .and_then(|value| clean_optional_string(Some(value)))
294}
295
296fn env_optional_path(key: &str) -> Option<std::path::PathBuf> {
297    env_optional_string(key).map(std::path::PathBuf::from)
298}
299
300fn env_optional_u64(key: &str) -> Option<u64> {
301    env_optional_string(key).and_then(|value| value.parse::<u64>().ok())
302}
303
304/// codex 0547 F1+F2 (P2) — explicit user-supplied credential / secret 文件
305/// 读取 helper. 用户**显式**(CLI flag 或 TOML/XML config) 传入路径但读取失败
306/// 时 fail-closed 返 Err. 让 daemon abort 而非 silent fallback.
307///
308/// **范围**: 只覆盖 user-supplied (explicit) credential 路径; auto-detect 路径
309/// 由 caller 自己选 fallback 行为. 当前调用点:
310///
311/// - `rsa_private_key`: `--rsa-private-key` / `[rsa_private_key]` (F1)
312/// - `login_pwd_file`: `--login-pwd-file` / `[login_pwd_file]` (F2; 仍走老
313///   resolve_login_password 7 层链, 但 explicit path 失败 = fatal)
314///
315/// **fail-closed 触发**:
316/// - 文件不存在 / 权限不够 / IO error → Err
317/// - 文件存在但 trim 后为空 → Err (用户传了路径但内容空 = 不一致, 常见
318///   原因: systemd LoadCredential 失败 / Docker secret mount 漏挂 / file
319///   truncated)
320///
321/// **返回**: `Ok(content_after_trim_trailing_whitespace)`, 失败 `Err`.
322///
323/// 调用方对 `Err` 的标准处理是直接 `?` propagate 让 daemon abort.
324pub fn read_explicit_credential_file(field_label: &'static str, path: &str) -> Result<String> {
325    let raw = std::fs::read_to_string(path).map_err(|e| {
326        anyhow::anyhow!(
327            "audit 0547 (P2) fix: failed to read explicit {field_label} from {path}: {e}\n\
328             Explicit credential / secret 路径读失败现在 fail-closed (daemon \
329             abort), 不再 silent fallback. 检查: 文件存在 / 权限 / systemd \
330             LoadCredential= / Docker secret mount. 如要 explicit opt-out fail-closed \
331             behavior, 不传该 flag 即可 (会走 auto-detect / 其他来源)."
332        )
333    })?;
334    let content = raw.trim_end_matches(['\n', '\r', ' ', '\t']).to_string();
335    if content.is_empty() {
336        return Err(anyhow::anyhow!(
337            "audit 0547 (P2) fix: explicit {field_label} at {path} is empty (after \
338             trim). 不允许 (常见原因: secret mount 失败 / file truncated / write \
339             race). 修文件后重启."
340        ));
341    }
342    Ok(content)
343}
344
345/// 合并 CLI args + 配置文件 (CLI 优先)
346///
347/// 配置文件查找顺序:
348///   1. `--config <path>`:TOML(v1.4.2+)
349///   2. `--cfg-file <path>`:XML(兼容 C++ FutuOpenD.xml)
350///   3. 自动检测:同目录 futu-opend.toml → FutuOpenD.xml
351pub fn merge_config(args: Args) -> Result<RuntimeConfig> {
352    // v1.4.102 codex 24 F1 (P1) fix: explicit `--config` / `--cfg-file` 加载
353    // 失败必须 abort, 不再 silent fallback 到 default.
354    //
355    // **历史**: BUG-006 修法只让 `XmlConfig` 加 `deny_unknown_fields`, 但
356    // 实际 daemon 启动路径在这里把任何 parse error catch 后用 default 继续
357    // 跑. 用户写错的 `[daemon]` section 在 `XmlConfig::deserialize` 报错,
358    // 但 daemon 仍以 default 启动 (`port=11111` etc.) — silent ignore 并未
359    // 真正修在 runtime path 上.
360    //
361    // **修法 (codex 24 F1)**: explicit path → `?` 返 error abort daemon.
362    // **auto-detect path 仍 fallback** (用户没显式指定文件 → default 是合理
363    // 行为, 不改).
364    let xml = if let Some(ref path) = args.config {
365        // TOML 显式指定 → fail-closed (BUG-006 真修, codex 24 F1)
366        load_toml_config(path).map_err(|e| {
367            anyhow::anyhow!(
368                "failed to load explicit --config TOML at {path}: {e}\n\
369                 v1.4.102 codex 24 F1 (P1) fix: explicit config 解析失败 daemon abort \
370                 (不再 silent fallback to default)."
371            )
372        })?
373    } else if let Some(ref path) = args.cfg_file {
374        // XML 显式指定 → fail-closed
375        load_xml_config(path).map_err(|e| {
376            anyhow::anyhow!(
377                "failed to load explicit --cfg-file XML at {path}: {e}\n\
378                 v1.4.102 codex 24 F1 (P1) fix: explicit config 解析失败 daemon abort \
379                 (不再 silent fallback to default)."
380            )
381        })?
382    } else {
383        // 自动检测:优先 futu-opend.toml,其次 FutuOpenD.xml
384        let exe_dir = std::env::current_exe()
385            .ok()
386            .and_then(|p| p.parent().map(|d| d.to_path_buf()));
387        if let Some(ref dir) = exe_dir {
388            let toml_path = dir.join("futu-opend.toml");
389            let xml_path = dir.join("FutuOpenD.xml");
390            // v1.4.102 codex 31 F1 (P1) fix: auto-detect config 解析失败也
391            // fail-closed (与 explicit --config / --cfg-file 一致). 之前
392            // unwrap_or_default 让带 [daemon] section 的 auto-loaded TOML
393            // silent fallback to XmlConfig::default(), 与 BUG-006 fatal claim
394            // 不一致.
395            if toml_path.exists() {
396                load_toml_config(&toml_path.to_string_lossy()).map_err(|e| {
397                    anyhow::anyhow!(
398                        "failed to parse auto-detected futu-opend.toml at {}: {e}\n\
399                         v1.4.102 codex 31 F1 (P1): auto-detected config parse \
400                         failure 现在也 fail-closed (与 explicit --config 一致). \
401                         如不希望此文件加载, 删除或重命名即可.",
402                        toml_path.display()
403                    )
404                })?
405            } else if xml_path.exists() {
406                load_xml_config(&xml_path.to_string_lossy()).map_err(|e| {
407                    anyhow::anyhow!(
408                        "failed to parse auto-detected FutuOpenD.xml at {}: {e}\n\
409                         v1.4.102 codex 31 F1 (P1): auto-detected config parse \
410                         failure 现在也 fail-closed.",
411                        xml_path.display()
412                    )
413                })?
414            } else {
415                XmlConfig::default()
416            }
417        } else {
418            XmlConfig::default()
419        }
420    };
421
422    Ok(RuntimeConfig {
423        ip: args.ip.or(xml.ip).unwrap_or_else(|| "0.0.0.0".to_string()),
424        port: args.port.or(xml.port).unwrap_or(11111),
425        login_account: args.login_account.or(xml.login_account),
426        login_pwd: args.login_pwd.or(xml.login_pwd),
427        login_pwd_md5: args.login_pwd_md5.or(xml.login_pwd_md5),
428        login_pwd_file: args.login_pwd_file.or(xml.login_pwd_file),
429        // v1.4.40 #12 fix: LoginRegion enum (gz/sh/hk 三个合法值) → string 给下游 auth
430        // 使用。enum 已保证合法性,这里 as_str() 转回内部约定的 lowercase 形式。
431        // v1.4.42 (P3.5 澄清): login_region_explicit 记录 "是否用户显式传了",
432        // 用于 main() 早期对 moomoo 账户 WARN 这个 flag 对他们 noop。
433        login_region_explicit: args.login_region.is_some() || xml.login_region.is_some(),
434        login_region: args
435            .login_region
436            .or(xml.login_region)
437            .map(|r| r.as_str().to_string())
438            .unwrap_or_else(|| "gz".to_string()),
439        // v1.4.14:auth_server 优先级
440        //   1. `--auth-server <url>` 显式 URL(最高)
441        //   2. `--platform moomoo/futunn` → auth.moomoo.com / auth.futunn.com
442        //   3. XML/TOML 里的 platform 字段
443        //   4. 默认 auth.futunn.com
444        platform: args.platform.or(xml.platform).unwrap_or_default(),
445        auth_server: args.auth_server.unwrap_or_else(|| {
446            args.platform
447                .or(xml.platform)
448                .unwrap_or_default()
449                .auth_server()
450                .to_string()
451        }),
452        log_level: {
453            // codex 0547 F3 (P2) fix: XmlConfig.log_level 改 Option<LogLevel>
454            // 之后, 非法值在 toml::from_str / quick_xml::de::from_str 阶段直接
455            // reject (与 BUG-006 deny_unknown_fields 同语义级别). 这里只把
456            // CLI / config enum 转 lowercase string 给下游使用.
457            //
458            // v1.4.73 BUG-015 老语义 (eprintln + fallback to "info") 已被淘汰 —
459            // explicit user 输入有 typo 必须 daemon abort, 不能 silent
460            // 用 default 跑 (反模式 D / pitfall #45 silent-success).
461            args.log_level
462                .or(xml.log_level)
463                .map(|l| l.as_str().to_string())
464                .unwrap_or_else(|| "info".to_string())
465        },
466        websocket_port: args.websocket_port.or(xml.websocket_port),
467        telnet_port: args.telnet_port.or(xml.telnet_port),
468        // Ref: C++ FutuOpenD FTGateway.cpp:1300-1302 — empty
469        // GTW_CfgKey_TelnetIP falls back to 127.0.0.1, independent of the
470        // main API bind IP. Keep the same default so enabling telnet on a
471        // daemon bound to 0.0.0.0 does not accidentally expose the unauthenticated
472        // management port.
473        telnet_ip: args
474            .telnet_ip
475            .or(xml.telnet_ip)
476            .unwrap_or_else(|| "127.0.0.1".to_string()),
477        rest_port: args.rest_port.or(xml.rest_port),
478        grpc_port: args.grpc_port.or(xml.grpc_port),
479        rsa_private_key: {
480            // codex 0547 F1 (P2) fix: 显式 `--rsa-private-key` / TOML/XML
481            // `rsa_private_key` 路径读失败必须 fail-closed (返 Err 让 daemon
482            // abort), 不再 silent fallback to "no RSA"。systemd `LoadCredential=`
483            // / Docker secret mount 失败时 daemon 仍跑变成无 RSA 模式 = 安全
484            // 配置 silent 失效。统一走 `read_explicit_credential_file` helper.
485            let key_path = args.rsa_private_key.or(xml.rsa_private_key);
486            if let Some(ref path) = key_path {
487                let pem = read_explicit_credential_file("--rsa-private-key", path)?;
488                eprintln!("loaded RSA private key from {path}");
489                Some(pem)
490            } else {
491                None
492            }
493        },
494        device_id: args.device_id,
495        reset_device: args.reset_device,
496        setup_only: args.setup_only,
497        verify_code: args.verify_code,
498        json_log: args.json_log,
499        lang: merge_runtime_lang(args.lang, xml.lang),
500        language_pack_auto_update: args
501            .language_pack_auto_update
502            .or(xml.language_pack_auto_update)
503            .unwrap_or(true),
504        language_pack_endpoint: clean_optional_string(args.language_pack_endpoint)
505            .or_else(|| clean_optional_string(xml.language_pack_endpoint))
506            .or_else(|| env_optional_string("FUTU_LANGUAGE_PACK_ENDPOINT")),
507        language_pack_cache_dir: args
508            .language_pack_cache_dir
509            .or(xml.language_pack_cache_dir)
510            .or_else(|| env_optional_path("FUTU_LANGUAGE_PACK_CACHE_DIR")),
511        language_pack_update_timeout_ms: args
512            .language_pack_update_timeout_ms
513            .or(xml.language_pack_update_timeout_ms)
514            .or_else(|| env_optional_u64("FUTU_LANGUAGE_PACK_UPDATE_TIMEOUT_MS"))
515            .unwrap_or(3000),
516        // codex 0547 F6 (P3): 安全字段 CLI / TOML 双路径合并. CLI 优先, TOML
517        // fallback. allow_tcp_unauthenticated bool 字段无 explicit-false override
518        // (clap derive limit), 与 BUG-006 等价 — explicit-true 一定保留.
519        rest_keys_file: args.rest_keys_file.or(xml.rest_keys_file),
520        grpc_keys_file: args.grpc_keys_file.or(xml.grpc_keys_file),
521        ws_keys_file: args.ws_keys_file.or(xml.ws_keys_file),
522        audit_log: args.audit_log.or(xml.audit_log),
523        allow_tcp_unauthenticated: args.allow_tcp_unauthenticated
524            || xml.allow_tcp_unauthenticated.unwrap_or(false),
525        tz: args.tz.or(xml.tz),
526        // Long-runner client_sig auto-refresh stays explicit opt-in. Bool CLI
527        // flags follow the same "true overrides, false means absent" pattern as
528        // allow_tcp_unauthenticated; env compatibility is resolved in gateway-core.
529        client_sig_proactive_refresh: args.client_sig_proactive_refresh
530            || xml.client_sig_proactive_refresh.unwrap_or(false),
531        client_sig_reactive_refresh: args.client_sig_reactive_refresh
532            || xml.client_sig_reactive_refresh.unwrap_or(false),
533    })
534}
535
536#[cfg(test)]
537mod tests;