1use 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#[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 acc_ids: Vec<u64>,
77 format: crate::output::OutputFormat,
80) -> Result<()> {
81 let (client, _push_rx) = connect_gateway(gateway, "futucli-unlock").await?;
82
83 if lock {
84 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 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 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
408pub 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
434pub 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
451pub 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
476pub 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;