Skip to main content

futucli/cmd/
unlock.rs

1//! `futucli unlock-trade` — 解锁 / 锁回交易
2//!
3//! 对 gateway 执行一次 UnlockTrade。成功后 gateway 进程级缓存会持有 cipher,
4//! 后续所有客户端(futucli / futu-mcp / Python)的下单都能自动拿到 cipher,
5//! **直到 gateway 重启**。
6//!
7//! 密码来源优先级:
8//! 1. `--from-stdin`:从 stdin 读一整行(脚本友好)
9//! 2. 环境变量 `FUTU_TRADE_PWD`
10//! 3. 账号级 OS credential store(macOS 发布包仅兼容读取旧条目)
11//! 4. legacy OS keychain(v1.4.109 前全局 `trade-password` 条目)
12//! 5. 交互式 tty prompt(无回显)
13//!
14//! 明文密码不会出现在命令行参数里,避免 shell history / `/proc/*/cmdline` 泄露。
15//! MD5 在本地计算后再发送。
16
17use std::io::{self, BufRead};
18
19use anyhow::{Context, Result, bail};
20use futu_auth::secret_store::{DeleteOutcome, PasswordLookup, SecretStoreError};
21use serde::Serialize;
22
23use crate::common::connect_gateway;
24
25/// CLI 端的 SecurityFirm 枚举:clap ValueEnum 同时接受官方名称(FutuHK / FutuUS)
26/// 和短别名(hk / us)。值映射到 proto `Trd_Common.SecurityFirm` int32。
27#[derive(Debug, Clone, Copy, clap::ValueEnum)]
28#[non_exhaustive]
29pub enum SecurityFirmArg {
30    #[value(name = "FutuHK", alias = "hk", alias = "futu-hk", alias = "1")]
31    FutuHK,
32    #[value(
33        name = "FutuUS",
34        alias = "us",
35        alias = "futu-us",
36        alias = "2",
37        alias = "moomoo",
38        alias = "mm"
39    )]
40    FutuUS,
41    #[value(name = "FutuSG", alias = "sg", alias = "futu-sg", alias = "3")]
42    FutuSG,
43    #[value(name = "FutuAU", alias = "au", alias = "futu-au", alias = "4")]
44    FutuAU,
45    #[value(name = "FutuCA", alias = "ca", alias = "futu-ca", alias = "5")]
46    FutuCA,
47    #[value(name = "FutuMY", alias = "my", alias = "futu-my", alias = "6")]
48    FutuMY,
49    #[value(name = "FutuJP", alias = "jp", alias = "futu-jp", alias = "7")]
50    FutuJP,
51}
52
53impl SecurityFirmArg {
54    pub fn as_i32(self) -> i32 {
55        match self {
56            Self::FutuHK => 1,
57            Self::FutuUS => 2,
58            Self::FutuSG => 3,
59            Self::FutuAU => 4,
60            Self::FutuCA => 5,
61            Self::FutuMY => 6,
62            Self::FutuJP => 7,
63        }
64    }
65}
66
67pub async fn run(
68    gateway: &str,
69    lock: bool,
70    from_stdin: bool,
71    trade_pwd_account: Option<&str>,
72    otp: Option<String>,
73    security_firm: Option<SecurityFirmArg>,
74    // v1.4.34: 只解锁这些 acc_ids(空 = 不 per-account filter)。和 security_firm
75    // 同时传时是交集。解决同 broker 内影子账户拖垮主账户的场景。
76    acc_ids: Vec<u64>,
77    // v1.4.98 external reviewer BUG-005 fix (P2, 2026-04-27): 加 format param 让 lock/unlock
78    // 路径都能 honor `-o json` (脚本/agent 用).
79    format: crate::output::OutputFormat,
80) -> Result<()> {
81    let (client, _push_rx) = connect_gateway(gateway, "futucli-unlock").await?;
82
83    if lock {
84        // lock 时 pwd_md5 不参与校验,传空串即可
85        futu_trd::account::unlock_trade(
86            &client,
87            "",
88            false,
89            None,
90            security_firm.map(|s| s.as_i32()),
91            acc_ids,
92        )
93        .await
94        .context("lock trade failed")?;
95        match format {
96            crate::output::OutputFormat::Json | crate::output::OutputFormat::Jsonl => {
97                let outcome = futu_trd::account::UnlockTradeOutcome {
98                    total_requested: 0,
99                    total_unlocked: 0,
100                    need_otp: false,
101                    failed_accounts: vec![],
102                    message: None,
103                };
104                println!(
105                    "{}",
106                    render_unlock_trade_output(format, "lock", gateway, &outcome)?
107                );
108            }
109            _ => {
110                println!("Trade locked on gateway {gateway}.");
111            }
112        }
113        return Ok(());
114    }
115
116    let pwd = read_password(from_stdin, trade_pwd_account)?;
117    if pwd.is_empty() {
118        bail!("empty password");
119    }
120    let pwd_md5 = format!("{:x}", md5::compute(pwd.as_bytes()));
121
122    let outcome = futu_trd::account::unlock_trade(
123        &client,
124        &pwd_md5,
125        true,
126        otp.as_deref(),
127        security_firm.map(|s| s.as_i32()),
128        acc_ids,
129    )
130    .await
131    .context("unlock trade failed")?;
132
133    // v1.4.31: 显示 per-broker per-account 结果
134    if matches!(
135        format,
136        crate::output::OutputFormat::Json | crate::output::OutputFormat::Jsonl
137    ) {
138        println!(
139            "{}",
140            render_unlock_trade_output(format, "unlock", gateway, &outcome)?
141        );
142        return Ok(());
143    }
144
145    if outcome.need_otp {
146        println!(
147            "⚠️  服务端要求 OTP / 令牌动态密码。失败账户:{:?}",
148            outcome.failed_accounts
149        );
150        println!(
151            "  重试:`futucli unlock-trade --otp REPLACE_WITH_6DIGIT_OTP`(保留相同密码来源)"
152        );
153        println!(
154            "  ⚠️  把 `REPLACE_WITH_6DIGIT_OTP` 换成富途令牌 app 里当前显示的 6 位动态密码,别原样粘贴"
155        );
156        return Ok(());
157    }
158    println!(
159        "Trade unlock: {}/{} accounts unlocked.",
160        outcome.total_unlocked, outcome.total_requested
161    );
162    if outcome.total_unlocked < outcome.total_requested {
163        println!(
164            "⚠️  失败账户(常见原因:该账户品种权限未开通 / 影子子账户):{:?}",
165            outcome.failed_accounts
166        );
167        if let Some(msg) = &outcome.message {
168            println!("  daemon 信息:{msg}");
169        }
170    }
171    println!("Cipher is cached in the gateway process; will expire when gateway restarts.");
172    Ok(())
173}
174
175#[derive(Serialize)]
176struct UnlockTradeCliOutput<'a> {
177    ok: bool,
178    action: &'a str,
179    gateway: &'a str,
180    total_requested: usize,
181    total_unlocked: usize,
182    need_otp: bool,
183    failed_accounts: &'a [u64],
184    #[serde(skip_serializing_if = "Option::is_none")]
185    message: Option<&'a str>,
186    cipher_cached: bool,
187}
188
189fn render_unlock_trade_output(
190    format: crate::output::OutputFormat,
191    action: &str,
192    gateway: &str,
193    outcome: &futu_trd::account::UnlockTradeOutcome,
194) -> Result<String> {
195    let output = UnlockTradeCliOutput {
196        ok: !outcome.need_otp && outcome.total_unlocked == outcome.total_requested,
197        action,
198        gateway,
199        total_requested: outcome.total_requested,
200        total_unlocked: outcome.total_unlocked,
201        need_otp: outcome.need_otp,
202        failed_accounts: &outcome.failed_accounts,
203        message: outcome.message.as_deref(),
204        cipher_cached: action == "unlock" && !outcome.need_otp && outcome.total_unlocked > 0,
205    };
206
207    match format {
208        crate::output::OutputFormat::Json => {
209            serde_json::to_string_pretty(&output).map_err(Into::into)
210        }
211        crate::output::OutputFormat::Jsonl => serde_json::to_string(&output).map_err(Into::into),
212        crate::output::OutputFormat::Table | crate::output::OutputFormat::Markdown => Ok(format!(
213            "Trade {action}: {}/{} accounts unlocked.",
214            outcome.total_unlocked, outcome.total_requested
215        )),
216    }
217}
218
219fn read_password(from_stdin: bool, trade_pwd_account: Option<&str>) -> Result<String> {
220    if from_stdin {
221        let mut line = String::new();
222        io::stdin()
223            .lock()
224            .read_line(&mut line)
225            .context("read password from stdin")?;
226        return Ok(trim_stdin_password_line(&line));
227    }
228
229    let trade_pwd_account_env = std::env::var("FUTU_TRADE_PWD_ACCOUNT").ok();
230    let futu_account_env = std::env::var("FUTU_ACCOUNT").ok();
231    let env_pwd = std::env::var("FUTU_TRADE_PWD").ok();
232
233    if let Some(p) = trade_password_from_sources(
234        trade_pwd_account,
235        trade_pwd_account_env.as_deref(),
236        futu_account_env.as_deref(),
237        env_pwd.as_deref(),
238        read_keyring_password_entry,
239    )
240    .map_err(keyring_read_error)?
241    {
242        return Ok(p);
243    }
244
245    rpassword::prompt_password("Trade password: ").context("read password from tty")
246}
247
248fn non_empty_trimmed(s: &str) -> Option<String> {
249    let s = s.trim();
250    (!s.is_empty()).then(|| s.to_string())
251}
252
253fn trade_pwd_account_from(
254    explicit: Option<&str>,
255    trade_pwd_account_env: Option<&str>,
256    futu_account_env: Option<&str>,
257) -> Option<String> {
258    explicit
259        .and_then(non_empty_trimmed)
260        .or_else(|| trade_pwd_account_env.and_then(non_empty_trimmed))
261        .or_else(|| futu_account_env.and_then(non_empty_trimmed))
262}
263
264fn read_keyring_password_entry(
265    username: &str,
266) -> std::result::Result<Option<String>, SecretStoreError> {
267    match futu_auth::secret_store::read_password(username) {
268        Ok(PasswordLookup::Found(password)) => Ok(Some(password)),
269        Ok(PasswordLookup::Empty | PasswordLookup::NoEntry) => Ok(None),
270        // Preserve the existing fallback for ordinary backend errors. A timeout
271        // or dead worker is different: treating it as NoEntry would hide the
272        // Issue #31 startup/CLI stall and might prompt for another secret.
273        Err(SecretStoreError::Backend { .. }) => Ok(None),
274        Err(error) => Err(error),
275    }
276}
277
278fn trade_password_from_sources(
279    account_hint: Option<&str>,
280    trade_pwd_account_env: Option<&str>,
281    futu_account_env: Option<&str>,
282    env_pwd: Option<&str>,
283    mut read_keyring: impl FnMut(&str) -> std::result::Result<Option<String>, SecretStoreError>,
284) -> std::result::Result<Option<String>, SecretStoreError> {
285    if let Some(pwd) = env_pwd.and_then(non_empty_trimmed) {
286        return Ok(Some(pwd));
287    }
288
289    if let Some(account) =
290        trade_pwd_account_from(account_hint, trade_pwd_account_env, futu_account_env)
291    {
292        let scoped_username = futu_auth::keyring_username_for_trade_pwd(&account);
293        if let Some(pwd) = read_keyring(&scoped_username)? {
294            return Ok(Some(pwd));
295        }
296    }
297
298    read_keyring(futu_auth::KEYRING_USERNAME_TRADE_PWD)
299}
300
301fn keyring_read_error(error: SecretStoreError) -> anyhow::Error {
302    anyhow::anyhow!(
303        "OS keychain trade-password lookup failed within the {}-second safety boundary: {error}. \
304         Use FUTU_TRADE_PWD or `futucli unlock-trade --from-stdin`. {}",
305        futu_auth::secret_store::KEYRING_OPERATION_TIMEOUT.as_secs(),
306        keyring_platform_detail()
307    )
308}
309
310#[cfg(target_os = "macos")]
311fn keyring_platform_detail() -> &'static str {
312    "Packaged cross-binary Keychain sharing is not supported."
313}
314
315#[cfg(not(target_os = "macos"))]
316fn keyring_platform_detail() -> &'static str {
317    "The platform credential-store operation did not finish safely."
318}
319
320fn trim_stdin_password_line(line: &str) -> String {
321    line.trim_end_matches(['\n', '\r']).to_string()
322}
323
324fn read_keychain_password(kind: &str, from_stdin: bool) -> Result<String> {
325    if from_stdin {
326        let mut line = String::new();
327        io::stdin()
328            .lock()
329            .read_line(&mut line)
330            .with_context(|| format!("read {kind} password from stdin"))?;
331        let password = trim_stdin_password_line(&line);
332        if password.is_empty() {
333            bail!("empty password");
334        }
335        return Ok(password);
336    }
337
338    let pwd1 = rpassword::prompt_password(format!("{kind} password: "))
339        .context("read password from tty")?;
340    if pwd1.is_empty() {
341        bail!("empty password");
342    }
343    let pwd2 = rpassword::prompt_password("Confirm password: ").context("read confirm from tty")?;
344    if pwd1 != pwd2 {
345        bail!("passwords do not match");
346    }
347    Ok(pwd1)
348}
349
350fn read_password_for_keychain_write(
351    kind: &str,
352    from_stdin: bool,
353    read: impl FnOnce(&str, bool) -> Result<String>,
354) -> Result<String> {
355    ensure_cross_binary_keychain_write_supported(kind)?;
356    read(kind, from_stdin)
357}
358
359#[cfg(target_os = "macos")]
360fn ensure_cross_binary_keychain_write_supported(kind: &str) -> Result<()> {
361    let alternative = if kind == "Login" {
362        "store the password in a mode-0600 file and start futu-opend with --login-pwd-file"
363    } else {
364        "use FUTU_TRADE_PWD or futucli unlock-trade --from-stdin"
365    };
366    bail!(
367        "{kind} password was not read: packaged macOS futucli/futu-opend/futu-mcp binaries \
368         have distinct ad-hoc signing identities, so cross-binary Keychain sharing is not \
369         supported; {alternative}"
370    )
371}
372
373#[cfg(not(target_os = "macos"))]
374fn ensure_cross_binary_keychain_write_supported(_kind: &str) -> Result<()> {
375    Ok(())
376}
377
378fn secret_store_write_error(kind: &str, error: SecretStoreError) -> anyhow::Error {
379    anyhow::anyhow!(
380        "write {kind} password to OS credential store failed within the {}-second safety \
381         boundary: {error}. {}",
382        futu_auth::secret_store::KEYRING_OPERATION_TIMEOUT.as_secs(),
383        mutation_outcome_note(&error)
384    )
385}
386
387fn secret_store_delete_error(kind: &str, error: SecretStoreError) -> anyhow::Error {
388    anyhow::anyhow!(
389        "delete {kind} password from OS credential store failed within the {}-second safety \
390         boundary: {error}. {} On macOS, retry manually with `security delete-generic-password \
391         -s futu-opend-rs -a '{kind}-password.<account>'`.",
392        futu_auth::secret_store::KEYRING_OPERATION_TIMEOUT.as_secs(),
393        mutation_outcome_note(&error)
394    )
395}
396
397fn mutation_outcome_note(error: &SecretStoreError) -> &'static str {
398    match error {
399        SecretStoreError::Timeout { .. } | SecretStoreError::WorkerDisconnected { .. } => {
400            "The operation outcome is unknown; verify the credential store before retrying cleanup."
401        }
402        SecretStoreError::OperationInFlight
403        | SecretStoreError::CircuitOpen
404        | SecretStoreError::Backend { .. } => "No successful mutation was confirmed.",
405    }
406}
407
408/// `futucli set-trade-pwd --account <id>` —— 把交易密码写入 OS keychain。
409///
410/// 每个登录账号一条独立条目(username = `trade-password.<account>`),避免
411/// 多账号互相覆盖。`futucli unlock-trade` / `futu-mcp` 读取时通过
412/// `--trade-pwd-account` / `FUTU_TRADE_PWD_ACCOUNT` 选择对应条目。
413pub async fn set_trade_pwd(account: &str, from_stdin: bool) -> Result<()> {
414    let account = account.trim();
415    if account.is_empty() {
416        bail!("--account is required");
417    }
418    let pwd = read_password_for_keychain_write("Trade", from_stdin, read_keychain_password)?;
419    let username = futu_auth::keyring_username_for_trade_pwd(account);
420    futu_auth::secret_store::set_password(&username, &pwd)
421        .map_err(|error| secret_store_write_error("trade", error))?;
422    println!(
423        "✓ trade password saved to OS keychain (service={}, account={})",
424        futu_auth::KEYRING_SERVICE,
425        username
426    );
427    println!(
428        "  futucli unlock-trade / futu-mcp read it with --trade-pwd-account {account} \
429         (or FUTU_TRADE_PWD_ACCOUNT={account})."
430    );
431    Ok(())
432}
433
434/// `futucli clear-trade-pwd --account <id>` —— 从 OS keychain 删除某账号的交易密码。
435pub async fn clear_trade_pwd(account: &str) -> Result<()> {
436    let account = account.trim();
437    if account.is_empty() {
438        bail!("--account is required");
439    }
440    let username = futu_auth::keyring_username_for_trade_pwd(account);
441    match futu_auth::secret_store::delete_credential(&username) {
442        Ok(DeleteOutcome::Deleted) => {
443            println!("✓ trade password removed from OS keychain (account={account})")
444        }
445        Ok(DeleteOutcome::NoEntry) => println!("(no entry existed; nothing to remove)"),
446        Err(error) => return Err(secret_store_delete_error("trade", error)),
447    }
448    Ok(())
449}
450
451/// `futucli set-login-pwd --account <id>` —— 把登录密码写入 OS keychain(v1.4.18+)。
452///
453/// 每个账号一条独立条目(username = `login-password.<account>`),避免多账号
454/// 互相覆盖。`futu-opend` 启动时如果没传 `--login-pwd` / `FUTU_PWD`,会从
455/// 这里读取对应 account 的密码。
456///
457/// macOS 发布包的三个 binary 使用不同 ad-hoc signing identity,因此该命令
458/// 在读取密码前 fail-closed;Linux / Windows 继续使用原生 credential store。
459pub async fn set_login_pwd(account: &str, from_stdin: bool) -> Result<()> {
460    if account.is_empty() {
461        bail!("--account is required");
462    }
463    let pwd = read_password_for_keychain_write("Login", from_stdin, read_keychain_password)?;
464    let username = futu_auth::keyring_username_for_login_pwd(account);
465    futu_auth::secret_store::set_password(&username, &pwd)
466        .map_err(|error| secret_store_write_error("login", error))?;
467    println!(
468        "✓ login password saved to OS keychain (service={}, account={})",
469        futu_auth::KEYRING_SERVICE,
470        username
471    );
472    println!("  futu-opend will read it automatically when --login-pwd / FUTU_PWD is not set.");
473    Ok(())
474}
475
476/// `futucli clear-login-pwd --account <id>` —— 从 OS keychain 删除某账号的登录密码。
477pub async fn clear_login_pwd(account: &str) -> Result<()> {
478    if account.is_empty() {
479        bail!("--account is required");
480    }
481    let username = futu_auth::keyring_username_for_login_pwd(account);
482    match futu_auth::secret_store::delete_credential(&username) {
483        Ok(DeleteOutcome::Deleted) => {
484            println!("✓ login password removed from OS keychain (account={account})")
485        }
486        Ok(DeleteOutcome::NoEntry) => println!("(no entry existed; nothing to remove)"),
487        Err(error) => return Err(secret_store_delete_error("login", error)),
488    }
489    Ok(())
490}
491
492#[cfg(test)]
493mod tests;