Skip to main content

futu_opend/
credentials.rs

1//! v1.4.110 P1-2: 凭据解析 helper 抽自 main.rs lines 844-973.
2
3#![allow(unused_imports)]
4
5use anyhow::Result;
6
7use crate::config::{RuntimeConfig, read_explicit_credential_file};
8
9/// 按 7 层优先级解析登录密码(v1.4.18+)。返回 `(password, is_md5)`。
10///
11/// 优先级(高到低):
12///   1. `--login-pwd-file <path>`   读文件(Docker secrets / systemd LoadCredential)
13///   2. `--login-pwd <plain>`       明文 argv(打 deprecation WARN)
14///   3. `--login-pwd-md5 <hex>`     md5 argv(同样 WARN)
15///   4. `FUTU_PWD` env var
16///   5. OS keychain(`futucli set-login-pwd --account X` 存的)
17///   6. 交互式 tty prompt(`rpassword`,不回显不进 history)
18///   7. 都没有 → `None`
19///
20/// `account` 用来查 keychain 条目(每账号一条,`login-password.<account>`)。
21///
22/// codex 0547 F2 (P2) fix: 显式 `--login-pwd-file` / `[login_pwd_file]` 配置
23/// 路径读取失败 / 内容空 = fatal Err. 不再 silent fallback to 后续 6 项 (老
24/// 行为)。systemd `LoadCredential=` / Docker secret mount 失败时, 之前 daemon
25/// 会用旧 `FUTU_PWD` / keychain 密码继续登 → 用户以为换密码, 实际还在用旧值
26/// (silent failure 反模式 / pitfall #45). explicit ≠ auto-detect; auto-detect
27/// 该 silent fallback, explicit 该 fail-closed.
28///
29/// 返回 `Result<Option<(Option<String>, bool)>>`:
30/// - `Ok(Some((Some(pwd), is_md5)))` — 找到密码 (任一来源)
31/// - `Ok(None)` — 7 层全无 (caller 按 "无凭据" 处理, e.g. offline mode)
32/// - `Err(...)` — explicit 来源 #1 (login_pwd_file) 失败, daemon 应 abort
33pub fn resolve_login_password(
34    account: Option<&str>,
35    config: &RuntimeConfig,
36) -> Result<Option<(Option<String>, bool)>> {
37    // 1. --login-pwd-file (显式 → fail-closed per codex 0547 F2)
38    //
39    // 之前 v1.4.18 - v1.4.105: read 失败 / empty → tracing::warn + 走下一项.
40    // 修后: 用户**显式**给了 path 但读失败 / 文件空 = fatal, 不 silent
41    // fallback. 走 `read_explicit_credential_file` helper 与 F1 保持一致.
42    if let Some(path) = &config.login_pwd_file {
43        let pwd = read_explicit_credential_file("--login-pwd-file", path)?;
44        tracing::info!(path = %path, "loaded login password from --login-pwd-file");
45        return Ok(Some((Some(pwd), false)));
46    }
47
48    // 2. --login-pwd(明文)—— 保留但打 deprecation WARN
49    if let Some(pwd) = &config.login_pwd
50        && !pwd.is_empty()
51    {
52        tracing::warn!(
53            "⚠️  --login-pwd passes plaintext password via argv; visible in `ps aux` \
54                 and shell history. Recommended: `futucli set-login-pwd --account {}` \
55                 to store in OS keychain, then omit --login-pwd.",
56            account.unwrap_or("<account>")
57        );
58        return Ok(Some((Some(pwd.clone()), false)));
59    }
60
61    // 3. --login-pwd-md5 —— 同样 WARN(md5 等同明文,可以直接登录)
62    if let Some(md5) = &config.login_pwd_md5
63        && !md5.is_empty()
64    {
65        tracing::warn!(
66            "⚠️  --login-pwd-md5 is equivalent to plaintext (can log in directly); \
67                 same argv exposure as --login-pwd. Recommended: use `futucli set-login-pwd` \
68                 instead."
69        );
70        return Ok(Some((Some(md5.clone()), true)));
71    }
72
73    // 4. FUTU_PWD env var
74    if let Ok(pwd) = std::env::var("FUTU_PWD")
75        && !pwd.is_empty()
76    {
77        tracing::info!("loaded login password from FUTU_PWD env var");
78        return Ok(Some((Some(pwd), false)));
79    }
80
81    // 5. OS keychain —— 需要知道 account 才能查对应条目
82    if let Some(acc) = account {
83        // v1.4.57 UX-08 (外部报告): Keychain 读取耗时可达 10s+(macOS 首次解锁),
84        // 加 INFO 提示让用户不以为 daemon 挂死。
85        tracing::info!(
86            account = acc,
87            "loading login password from OS keychain (may take ~10s on first unlock)"
88        );
89        let username = futu_auth::keyring_username_for_login_pwd(acc);
90        match keyring::Entry::new(futu_auth::KEYRING_SERVICE, &username) {
91            Ok(entry) => match entry.get_password() {
92                Ok(pwd) if !pwd.is_empty() => {
93                    tracing::info!(account = acc, "loaded login password from OS keychain");
94                    return Ok(Some((Some(pwd), false)));
95                }
96                Ok(_) => {
97                    tracing::debug!(account = acc, "keychain entry exists but is empty");
98                }
99                Err(keyring::Error::NoEntry) => {
100                    tracing::debug!(account = acc, "no keychain entry for this account");
101                }
102                Err(e) => {
103                    tracing::warn!(account = acc, error = %e, "keychain read failed");
104                }
105            },
106            Err(e) => {
107                tracing::warn!(error = %e, "keychain backend unavailable");
108            }
109        }
110    }
111
112    // 6. 交互式 prompt(stdin 是 tty 时)
113    if std::io::IsTerminal::is_terminal(&std::io::stdin())
114        && let Some(acc) = account
115    {
116        match rpassword::prompt_password(format!("Login password for account {acc}: ")) {
117            Ok(pwd) if !pwd.is_empty() => {
118                tracing::info!("loaded login password from interactive prompt");
119                // 提示用户下次可以走 keychain 免输
120                eprintln!(
121                    "  tip: run `futucli set-login-pwd --account {acc}` once to \
122                         skip this prompt next time."
123                );
124                return Ok(Some((Some(pwd), false)));
125            }
126            Ok(_) => {
127                tracing::warn!("empty password from prompt");
128            }
129            Err(e) => {
130                tracing::warn!(error = %e, "prompt_password failed");
131            }
132        }
133    }
134
135    // 7. 都没有
136    Ok(None)
137}